Skip to content

🏘️ feat: Add Hosted App Control Plane - #58

Open
danny-avila wants to merge 8 commits into
mainfrom
danny-avila/hosted-app-control-plane
Open

🏘️ feat: Add Hosted App Control Plane#58
danny-avila wants to merge 8 commits into
mainfrom
danny-avila/hosted-app-control-plane

Conversation

@danny-avila

@danny-avila danny-avila commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the stateful-profile control-plane half of resident hosted apps on top of #57.

  • snapshots one exact owner-bound stateful workspace revision and restores it into a dedicated app-host MicroVM
  • persists fenced/idempotent lifecycle intent and encrypted short-lived preview credentials
  • isolates lifecycle work on a stateful-only BullMQ queue while keeping disabled/default deployments free of extra hosted-app queue clients
  • exposes authenticated start/status/stop APIs and a wildcard, per-app unprivileged preview gateway
  • strips privileged headers/cookies, pins proxy traffic to the AWS endpoint origin, constrains browser subresource/fetch capabilities with a gateway-owned CSP, and supports streamed HTTP/SSE
  • validates split API/worker config and documents wildcard DNS/TLS and deployment requirements

Safety and lifecycle properties

  • immutable (app_id, revision, spec) contract
  • exact provider-request fingerprint plus persisted client token for ambiguous-launch replay
  • same-token replay resumes a suspended accepted VM instead of rotating the token and orphaning it
  • stop reconciles a no-ID ambiguous launch before termination and never erases a possibly-live provider intent
  • destructive replacement is durably recorded as TERMINATING before the AWS call, so later Redis/generation failures cannot leave a dead VM advertised as running
  • provider startedAt anchors the lease deadline; ambiguous expiry also budgets provider-call latency
  • per-app and source-workspace locking with fencing/heartbeat semantics
  • stopped source VMs reuse their last committed checkpoint; live sources checkpoint and read the pointer under one lock
  • failed termination never drops the known VM id or promotes a partial launch to running
  • app-origin capabilities are owner- and revision-bound, short-lived, host-only, and use a key distinct from stored-credential encryption
  • expired leases/credentials fail closed; stored provider details are redacted from public status
  • refreshed preview records are re-authorized after the worker round trip
  • preview responses are non-storable, redirects resolve against the current route and stay same-origin, and original query bytes are preserved
  • arbitrary app routes are collapsed before Prometheus labeling; lazy hosted-app queue resources have explicit error handlers
  • app requests cannot override root-launch-sensitive environment variables
  • the control-plane ports/readiness timeout are fixed to the pinned image contract instead of exposing ineffective overrides
  • safe runner 4xx errors retain their classification across BullMQ; provider details remain redacted
  • browser fetches/subresources remain same-origin and workers/service workers are disabled across revisions

Browser boundary

A top-level app document can still navigate the owner's browser to another origin. This experimental viewer is therefore owner-trusted. Before broad untrusted enablement, app content must be placed on a separate origin inside a sandboxed gateway wrapper. The runbook states this restriction explicitly.

Verification

  • bun test service/src/hosted-app/*.test.ts service/src/middleware/httpMetrics.test.ts service/src/secure-startup.test.ts β€” 82 pass
  • bun run test in service β€” 604 pass
  • bun run build in service β€” pass (only two pre-existing TS2352 warnings)
  • git diff --check β€” pass
  • exact-head CI β€” 5/5 green at 00c6ce7

Dependency

Stacked on #57 at 7aaab6c. Review this PR as the service/control-plane continuation; retarget it to main after #57 lands.

@danny-avila
danny-avila force-pushed the danny-avila/hosted-app-control-plane branch from 2d286a4 to e49f277 Compare August 21, 2026 15:43
@danny-avila danny-avila changed the title feat: add Lambda hosted-app control plane 🏘️ feat: Add Hosted App Control Plane Sep 2, 2026
Base automatically changed from danny-avila/hosted-app-runtime to main September 3, 2026 01:56
@danny-avila
danny-avila force-pushed the danny-avila/hosted-app-control-plane branch from 00c6ce7 to fad7fff Compare September 3, 2026 01:59
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Please review the current PR head fad7fff. Confirm that this exact commit is the reviewed commit and ignore findings that apply only to earlier heads. The stack was rebased onto merged #57/current main; git range-diff confirms all eight #58 patches are unchanged. Local hosted-app checks pass 93/93, the complete service suite passes 793/793, and the service bundle builds.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
πŸ“ Code Review βœ… Completed 2026-09-03T02:13:59.243087Z fad7fff Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with πŸ‘€ while any review is running, comments if it has suggestions, and reacts with πŸ‘ once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fad7fffc90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +277 to +279
const checkpointKey = exactRevision && prior?.hosted_app?.checkpoint_key
? prior.hosted_app.checkpoint_key
: await this.deps.captureCheckpoint(input.sourceRuntimeSessionId, input, signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain checkpoints pinned by hosted revisions

When the source workspace is checkpointed again after launching an app, checkpointSession() calls CheckpointStore.pruneOlderThan() and deletes the older object referenced here. If that hosted VM later expires or crashes, exact-revision recovery reuses the now-missing key, restoreSession() returns fetch_failed, and the immutable revision cannot be relaunched without changing its revision. Hosted snapshots need independent retention or pruning awareness.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment thread service/src/lifecycle.ts
pauseAndDrain(pyWorker, 'Python'),
pauseAndDrain(otherWorker, 'Other')
pauseAndDrain(otherWorker, 'Other'),
...(hostedAppWorker ? [pauseAndDrain(hostedAppWorker, 'Hosted app')] : []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow hosted jobs to drain past the global shutdown deadline

During a worker rollout, pause(false) waits for an active hosted job, but the enclosing gracefulShutdown() still forcibly exits after 30 seconds even though hosted starts are budgeted for several multi-minute checkpoint and launch legs. A normal long start can therefore be killed mid-transition, after which the stalled retry encounters the per-app Redis lock until its roughly twelve-minute default TTL expires. The shutdown deadline should accommodate these jobs or explicitly abort them with cleanup rather than claiming to drain them.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +280 to +282
const safeLocation = location
? rewriteHostedAppLocation(location, upstream.toString())
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve absolute redirects to the public app origin

When an app framework honors the forwarded host and emits an absolute Location for its browser-facing hosted-app origin, this check compares it only with the private AWS endpoint origin and converts the otherwise same-origin redirect into a 502. Since the proxy deliberately supplies X-Forwarded-Host, it should also recognize that exact public origin and rewrite it to a relative location while continuing to reject other origins.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +216 to +218
const retryToken = `${clientToken}-r1`;
const vm = await this.launchOnce(retryToken, deadlineAtMs, signal, deadlineSignal)
.catch(second => { throw launchFailure(second); });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the retry token when the second boot attempt times out

If the first VM reaches a terminal state and the -r1 attempt returns a PENDING VM near the shared launch deadline, the deadline can abort this attempt while it is polling. The raw sleep abort is passed through launchFailure() as a non-transient error, so the control plane retires the persisted original-token intent even though the -r1 VM may be live; its ID and retry token never reach the caller, and the next request can launch another VM. Timeout or caller-abort errors from this second attempt must preserve a replayable intent rather than being classified as definite failure.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +399 to +415
const response = await this.deps.fetch(
`${normalizeHostedAppMicrovmEndpoint(vm.endpoint ?? '')}/api/v2/hosted-app/start`,
{
method: 'POST',
headers: {
[token.headerName]: token.token,
...microvmPortHeaders(this.config.controlPort),
'X-Runtime-Session-Id': runtimeSessionId,
'Content-Type': 'application/json',
},
body: JSON.stringify(spec),
signal: AbortSignal.any([
callerSignal,
AbortSignal.timeout(this.config.appStartTimeoutMs),
]),
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize resident-start transport failures for recovery

When reasserting an existing RUNNING revision, a network reset or the resident-start timeout rejects this fetch() with a raw transport error. The fast recovery path only recycles the VM for transient HostedAppMicrovmError instances, so the raw error leaves the stale VM recorded as RUNNING and repeated starts do not enter the intended terminate-and-restore path. Non-caller transport failures here should be converted to a transient hosted-app error.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +114 to +120
res.setHeader('Set-Cookie', [
`${COOKIE_NAME}=${encodeURIComponent(sessionToken)}`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=Strict',
`Max-Age=${maxAge}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not allow HTTP origins with an always-Secure preview cookie

Outside production, startup explicitly permits an http: preview origin, but this authorization response always sets a Secure __Host- cookie. Browsers ignore that cookie on ordinary insecure origins, so the 303 reaches / without the session token and immediately returns Preview authorization required. Either require HTTPS in every environment or use a development cookie policy compatible with the allowed HTTP origin.

Useful? React with πŸ‘Β / πŸ‘Ž.

&& record.hard_deadline_at <= now
) return 'stopped';
if (record.state === 'RUNNING') return 'running';
if (record.state === 'PENDING') return 'starting';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop reporting expired pending launches as starting

An ambiguous provider failure deliberately leaves a no-ID record in PENDING, but once its provider lifetime and recovery window have elapsed there can no longer be a launch in progress. This unconditional mapping continues to return starting for the remaining Redis-record lifetime, misleading clients that poll status instead of issuing another start. Expired PENDING intents should transition to a terminal public state based on their deadline.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +190 to +196
if (
exactRevision
&& prior?.state === 'RUNNING'
&& prior.microvm_id
&& prior.endpoint
&& (prior.hard_deadline_at == null
|| prior.hard_deadline_at > this.now() + HOSTED_APP_DEADLINE_HEADROOM_MS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recycle running VMs when launch policy changes

The RUNNING fast path checks only the app revision/spec and ignores the persisted launch_fingerprint. After a deployment changes the hosted image version, execution role, ingress connectors, idle policy, or lifetime limits, starting the same revision therefore reasserts the old VM for up to its existing eight-hour lease instead of replacing it under the current launch and security policy. Require the current launch fingerprint before reusing a running VM.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +83 to +90
if (!hostedAppPreviewCredentialUsable(record, resolved, Date.now(), PREVIEW_REFRESH_SKEW_MS)) {
await submitHostedAppJob('hosted-app:refresh-preview', {
operation: 'refresh-preview',
hostedAppRuntimeId: resolved.hostedAppRuntimeId,
tenantId: record.tenant_id,
canonicalUserId: record.canonical_user_id,
_otel: captureTraceCarrier(),
}, `happ-refresh-${resolved.hostedAppRuntimeId}-${Math.floor(Date.now() / 30_000)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to a valid preview credential when refresh fails

During the final 60 seconds of an otherwise valid AWS credential, every preview request synchronously awaits this refresh job. If Redis, the hosted worker, or the per-app lease is temporarily unavailable, the exception escapes immediately and the gateway rejects the request even though the existing credential has not expired; the subsequent minimum-zero usability check is never reached. After a failed refresh, reread state and use the still-valid credential before returning an outage.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +154 to +158
bytes > env.MAX_FILE_SIZE
? new HostedAppControlPlaneError(
'hosted_app_request_too_large',
`Hosted app request exceeds ${env.MAX_FILE_SIZE} bytes`,
413,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the 413 status for oversized chunked requests

For a request without Content-Length, exceeding the limit raises this typed 413 from inside the body stream, but Node's fetch() rejects with a top-level transport TypeError whose cause is the stream error. The gateway consequently treats the request as an unknown upstream failure and returns 502 instead of the intended hosted_app_request_too_large response. Unwrap or otherwise propagate the limiter error across the fetch boundary.

Useful? React with πŸ‘Β / πŸ‘Ž.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants